package org.newdawn.slick.openal;

import org.lwjgl.BufferUtils;
import org.lwjgl.Sys;
import org.lwjgl.openal.AL10;
import org.lwjgl.openal.AL11;
import org.lwjgl.openal.OpenALException;
import org.newdawn.slick.util.Log;
import org.newdawn.slick.util.ResourceLoader;

import java.io.IOException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.IntBuffer;

/**
 * A generic tool to work on a supplied stream, pulling out PCM data and buffered it to OpenAL
 * as required.
 *
 * @author Kevin Glass
 * @author Nathan Sweet  {@literal <misc@n4te.com>}
 * @author Rockstar play and setPosition cleanup
 */
public class OpenALStreamPlayer {
    /**
     * The number of buffers to maintain
     */
    public static final int BUFFER_COUNT = 20;  // 3
    /**
     * The size of the sections to stream from the stream
     */
    private static final int SECTION_SIZE = 4096;  // 4096 * 20
    /**
     * The stream position.
     */
    long streamPos = 0;
    /**
     * The sample rate.
     */
    int sampleRate;
    /**
     * The sample size.
     */
    int sampleSize;
    /**
     * The play position.
     */
    long playedPos;
    /**
     * The music length.
     */
    long musicLength = -1;
    /**
     * The assumed time of when the music position would be 0.
     */
    long syncStartTime;
    /**
     * The last value that was returned for the music position.
     */
    float lastUpdatePosition = 0;
    /**
     * The average difference between the sync time and the music position.
     */
    float avgDiff;
    /**
     * The time when the music was paused.
     */
    long pauseTime;
    /**
     * The buffer read from the data stream
     */
    private byte[] buffer = new byte[SECTION_SIZE];
    /**
     * Holds the OpenAL buffer names
     */
    private IntBuffer bufferNames;
    /**
     * The byte buffer passed to OpenAL containing the section
     */
    private ByteBuffer bufferData = BufferUtils.createByteBuffer(SECTION_SIZE);
    /** Position in seconds of the previously played buffers */
    //    private float positionOffset;
    /**
     * The buffer holding the names of the OpenAL buffer thats been fully played back
     */
    private IntBuffer unqueued = BufferUtils.createIntBuffer(1);
    /**
     * The source we're playing back on
     */
    private int source;
    /**
     * The number of buffers remaining
     */
    private int remainingBufferCount;
    /**
     * True if we should loop the track
     */
    private boolean loop;
    /**
     * True if we've completed streaming to buffer (but may not be done playing)
     */
    private boolean done = true;
    /**
     * The stream we're currently reading from
     */
    private AudioInputStream audio;
    /**
     * The source of the data
     */
    private String ref;
    /**
     * The source of the data
     */
    private URL url;
    /**
     * The pitch of the music
     */
    private float pitch;

    /**
     * Create a new player to work on an audio stream
     *
     * @param source The source on which we'll play the audio
     * @param ref    A reference to the audio file to stream
     */
    public OpenALStreamPlayer(int source, String ref) {
        this.source = source;
        this.ref = ref;

        bufferNames = BufferUtils.createIntBuffer(BUFFER_COUNT);
        AL10.alGenBuffers(bufferNames);
    }

    /**
     * Create a new player to work on an audio stream
     *
     * @param source The source on which we'll play the audio
     * @param url    A reference to the audio file to stream
     */
    public OpenALStreamPlayer(int source, URL url) {
        this.source = source;
        this.url = url;

        bufferNames = BufferUtils.createIntBuffer(BUFFER_COUNT);
        AL10.alGenBuffers(bufferNames);
    }

    /**
     * Initialise our connection to the underlying resource
     *
     * @throws IOException Indicates a failure to open the underling resource
     */
    private void initStreams() throws IOException {
        if (audio != null) {
            audio.close();
        }

        AudioInputStream audioInputStream;

        if (url != null) {
            audioInputStream = new OggInputStream(url.openStream());
        } else {
            if (ref.toLowerCase().endsWith(".mp3")) {
                try {
                    audioInputStream = new Mp3InputStream(ResourceLoader.getResourceAsStream(ref));
                } catch (IOException e) {
                    // invalid MP3: check if file is actually OGG
                    try {
                        audioInputStream = new OggInputStream(ResourceLoader.getResourceAsStream(ref));
                    } catch (IOException e1) {
                        throw e;  // invalid OGG: re-throw original MP3 exception
                    }
                    if (audioInputStream.getRate() == 0 && audioInputStream.getChannels() == 0)
                        throw e;  // likely not OGG: re-throw original MP3 exception
                }
            } else {
                audioInputStream = new OggInputStream(ResourceLoader.getResourceAsStream(ref));
                if (audioInputStream.getRate() == 0 && audioInputStream.getChannels() == 0) {
                    // invalid OGG: check if file is actually MP3
                    AudioInputStream audioOGG = audioInputStream;
                    try {
                        audioInputStream = new Mp3InputStream(ResourceLoader.getResourceAsStream(ref));
                    } catch (IOException e) {
                        audioInputStream = audioOGG;  // invalid MP3: keep OGG stream
                    }
                }
            }
        }

        this.audio = audioInputStream;
        sampleRate = audioInputStream.getRate();
        if (audioInputStream.getChannels() > 1)
            sampleSize = 4; // AL10.AL_FORMAT_STEREO16
        else
            sampleSize = 2; // AL10.AL_FORMAT_MONO16
        //		positionOffset = 0;
        streamPos = 0;
        playedPos = 0;

    }

    /**
     * Get the source of this stream
     *
     * @return The name of the source of string
     */
    public String getSource() {
        return (url == null) ? ref : url.toString();
    }

    /**
     * Clean up the buffers applied to the sound source
     */
    private synchronized void removeBuffers() {
        AL10.alSourceStop(source);
        IntBuffer buffer = BufferUtils.createIntBuffer(1);

        while (AL10.alGetSourcei(source, AL10.AL_BUFFERS_QUEUED) > 0) {
            AL10.alSourceUnqueueBuffers(source, buffer);
            buffer.clear();
        }
    }

    /**
     * Start this stream playing
     *
     * @param loop True if the stream should loop
     * @throws IOException Indicates a failure to read from the stream
     */
    public synchronized void play(boolean loop) throws IOException {
        this.loop = loop;
        initStreams();

        done = false;

        AL10.alSourceStop(source);

        startPlayback();
        syncStartTime = getTime();
    }

    /**
     * Setup the playback properties
     *
     * @param pitch The pitch to play back at
     */
    public void setup(float pitch) {
        this.pitch = pitch;
        syncPosition();
    }

    /**
     * Check if the playback is complete. Note this will never
     * return true if we're looping
     *
     * @return True if we're looping
     */
    public boolean done() {
        return done;
    }

    /**
     * Poll the bufferNames - check if we need to fill the bufferNames with another
     * section.
     * <p/>
     * Most of the time this should be reasonably quick
     */
    public synchronized void update() {
        if (done) {
            return;
        }

        int processed = AL10.alGetSourcei(source, AL10.AL_BUFFERS_PROCESSED);
        while (processed > 0) {
            unqueued.clear();
            AL10.alSourceUnqueueBuffers(source, unqueued);

            int bufferIndex = unqueued.get(0);

            int bufferLength = AL10.alGetBufferi(bufferIndex, AL10.AL_SIZE);

            playedPos += bufferLength;

            if (musicLength > 0 && playedPos > musicLength)
                playedPos -= musicLength;

            if (stream(bufferIndex)) {
                AL10.alSourceQueueBuffers(source, unqueued);
            } else {
                remainingBufferCount--;
                if (remainingBufferCount == 0) {
                    done = true;
                }
            }
            processed--;
        }

        int state = AL10.alGetSourcei(source, AL10.AL_SOURCE_STATE);

        if (state != AL10.AL_PLAYING) {
            AL10.alSourcePlay(source);
        }
    }

    /**
     * Stream some data from the audio stream to the buffer indicates by the ID
     *
     * @param bufferId The ID of the buffer to fill
     * @return True if another section was available
     */
    public synchronized boolean stream(int bufferId) {
        try {
            int count = audio.read(buffer);
            if (count != -1) {
                streamPos += count;

                bufferData.clear();
                bufferData.put(buffer, 0, count);
                bufferData.flip();

                int format =
                    audio.getChannels() > 1 ? AL10.AL_FORMAT_STEREO16 : AL10.AL_FORMAT_MONO16;
                try {
                    AL10.alBufferData(bufferId, format, bufferData, audio.getRate());
                } catch (OpenALException e) {
                    Log.error(
                        "Failed to loop buffer: " + bufferId + " " + format + " " + count + " "
                            + audio.getRate(), e);
                    return false;
                }
            } else {
                if (loop) {
                    musicLength = streamPos;
                    initStreams();
                    stream(bufferId);
                } else {
                    done = true;
                    return false;
                }
            }

            return true;
        } catch (IOException e) {
            Log.error(e);
            return false;
        }
    }

    /**
     * Seeks to a position in the music.
     *
     * @param position Position in seconds.
     * @return True if the setting of the position was successful
     */
    public synchronized boolean setPosition(float position) {
        try {
            long samplePos = (long) (position * sampleRate) * sampleSize;

            if (streamPos > samplePos)
                initStreams();

            long skipped = audio.skip(samplePos - streamPos);
            if (skipped >= 0)
                streamPos += skipped;
            else
                Log.warn("OpenALStreamPlayer: setPosition: failed to skip.");

            while (streamPos + buffer.length < samplePos) {
                int count = audio.read(buffer);
                if (count != -1) {
                    streamPos += count;
                } else {
                    if (loop) {
                        initStreams();
                    } else {
                        done = true;
                    }
                    return false;
                }
            }

            playedPos = streamPos;
            syncStartTime =
                (long) (getTime() - (playedPos * 1000 / sampleSize / sampleRate) / pitch);

            startPlayback();

            return true;
        } catch (IOException e) {
            Log.error(e);
            return false;
        }
    }

    /**
     * Starts the streaming.
     */
    private void startPlayback() {
        removeBuffers();
        AL10.alSourcei(source, AL10.AL_LOOPING, AL10.AL_FALSE);
        AL10.alSourcef(source, AL10.AL_PITCH, pitch);

        remainingBufferCount = BUFFER_COUNT;

        for (int i = 0; i < BUFFER_COUNT; i++) {
            stream(bufferNames.get(i));
        }

        AL10.alSourceQueueBuffers(source, bufferNames);
        AL10.alSourcePlay(source);
    }

    /**
     * Return the current playing position in the sound
     *
     * @return The current position in seconds.
     */
    public float getALPosition() {
        float playedTime = ((float) playedPos / (float) sampleSize) / sampleRate;
        float timePosition = playedTime + AL10.alGetSourcef(source, AL11.AL_SEC_OFFSET);
        return timePosition;
    }

    /**
     * Return the current playing position in the sound
     *
     * @return The current position in seconds.
     */
    public float getPosition() {
        float thisPosition = getALPosition();
        long thisTime = getTime();
        float dxPosition = thisPosition - lastUpdatePosition;
        float dxTime = (thisTime - syncStartTime) * pitch;

        // hard reset
        if (Math.abs(thisPosition - dxTime / 1000f) > 1 / 2f) {
            syncPosition();
            dxTime = (thisTime - syncStartTime) * pitch;
            avgDiff = 0;
        }
        if ((int) (dxPosition * 1000) != 0) { // lastPosition != thisPosition
            float diff = thisPosition * 1000 - (dxTime);

            avgDiff = (diff + avgDiff * 9) / 10;
            if (Math.abs(avgDiff) >= 1) {
                syncStartTime -= (int) (avgDiff);
                avgDiff -= (int) (avgDiff);
                dxTime = (thisTime - syncStartTime) * pitch;
            }
            lastUpdatePosition = thisPosition;
        }

        return dxTime / 1000f;
    }

    /**
     * Synchronizes the track position.
     */
    private void syncPosition() {
        syncStartTime = getTime() - (long) (getALPosition() * 1000 / pitch);
        avgDiff = 0;
    }

    /**
     * Processes a track pause.
     */
    public void pausing() {
        pauseTime = getTime();
    }

    /**
     * Processes a track resume.
     */
    public void resuming() {
        syncStartTime += getTime() - pauseTime;
    }

    /**
     * http://wiki.lwjgl.org/index.php?title=LWJGL_Basics_4_%28Timing%29
     * Get the time in milliseconds
     *
     * @return The system time in milliseconds
     */
    public long getTime() {
        return (Sys.getTime() * 1000) / Sys.getTimerResolution();
    }

    /**
     * Closes the stream.
     */
    public void close() {
        if (audio != null) {
            try {
                audio.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

